Skip to content

SMOODEV-2820: th branding — white-label an org, logo included, from the CLI - #340

Merged
brentrager merged 3 commits into
mainfrom
SMOODEV-2820-th-branding
Aug 6, 2026
Merged

SMOODEV-2820: th branding — white-label an org, logo included, from the CLI#340
brentrager merged 3 commits into
mainfrom
SMOODEV-2820-th-branding

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

Problem

White-labeling a partner org meant clicking through the dashboard, and the logo step never really worked: the settings page takes a URL, so the partner's logo stays hosted on the partner's own server. There was also no scriptable path at all — no way to stand up a partner's branding from a terminal or a provisioning script.

Solution

New top-level th branding (alias th brand), the th crm precedent:

th branding show     [--org <id>] [--json]
th branding from-url <url> [--org <id>] [--apply] [--enable] [--no-logo] [--force] [--json]
th branding set      [--org <id>] [--app-name <s>] [--support-url <url>] [--hide-powered-by <bool>]
                     [--logo <path|url>] [--logo-dark <path|url>] [--favicon <path|url>]
                     [--color-scheme light|dark] [--primary <hex>] [--primary-foreground <hex>]
                     [--accent <hex>] [--accent-foreground <hex>] [--background <hex>]
                     [--foreground <hex>] [--card <hex>] [--border <hex>] [--muted <hex>]
                     [--muted-foreground <hex>] [--sidebar <hex>] [--sidebar-foreground <hex>]
th branding enable   [--org <id>] [--force]
th branding disable  [--org <id>]
th branding preview  [--org <id>]
th branding clear    [--org <id>] [--yes]

--logo / --logo-dark / --favicon each take a local path or a remote URL. A remote one is fetched and re-uploaded to the org's brand assets, so what lands in the branding row is always a URL we serve.

Three deliberate refusals

  • enable refuses an unreadable theme. WCAG contrast on the three foreground/background pairs, 4.5:1 floor, checked before the write. --force overrides, loudly. from-url --enable honours Lane A's contrast.passes:false and falls back to the local check when no report is present; disagreement resolves toward not shipping.
  • Remote logo URLs are vetted the way the server's vetUrl does — http(s) only, no loopback / RFC1918 / 169.254. (cloud metadata), no redirect following, 5 MB cap — and the bytes are magic-byte sniffed against the platform allowlist before upload. .ico (what link[rel=icon] usually yields) gets a named error rather than an opaque 400.
  • Every partial theme edit is a read-modify-write. The server's PUT replaces the whole themeJson column, so a naive set --primary would silently wipe every other token the partner had configured.

from-url is a dry run by default — swatch table, logo candidates, contrast verdict, zero writes. --apply stages (enabled stays false, previewable via ?brandPreview=1); --enable goes live.

Which upload endpoint, and why

POST /organizations/:org_id/logo/upload (multipart file + variant) — the only endpoint that puts brand images in the PUBLIC-read published-assets bucket. The presigned files/upload-url flow (files.rs) writes to the private bucket, whose URLs 403 everywhere they render (pearl th-8f3591 / SMOODEV-2566), which makes it unusable for a logo.

Its tradeoff is a side effect: it also stamps organizations.<variant>. That is correct for --logo (→ logo) and --favicon (→ icon) — same asset, another name. There is no dark slot, and the spare column logoWordmark is load-bearing (it renders in live customer proposals), so the dark upload borrows logo and puts the column back. Marked ponytail: with the upgrade path: add a logoDark variant to organization-logo-upload.ts and delete the workaround.

Locked

The Aurora meaning tokens (--color-heat-0..5, --color-ai, --gradient-aurora, ok/warn/crit) encode meaning, not chrome. No flags, and a test asserts they stay absent from a merged theme.

Verified live (prod, org "Smoo AI Test", fully restored afterward)

show (configured / staged / LIVE / --json) · set app-name + accent tokens · theme merge keeps untouched tokens · --logo from a local PNG · --favicon from a local SVG · --logo-dark from a remote URL (fetched, re-hosted, and organizations.logo confirmed restored, logoWordmark untouched) · enable passing 5.7:1 · enable refusing 1.3:1 and --force overriding · disable · preview · clear --yes · clear refusing on a non-tty · SSRF refusals for 169.254.169.254 and localhost · .ico and non-image rejections · the 400 and 404 diagnoses below.

Unit-tested only (no endpoint yet): from-url parsing/rendering + the --enable gate, against the frozen Lane A fixture. 28 tests; cargo fmt / clippy / test green (smooth-cli: 478 pass).

Two server-side gaps this surfaced

Both are pre-existing platform bugs, reported to the lead and Lane A, not introduced here — the CLI turns each into a diagnosis instead of a bare error:

  1. The white-label write validator is still Phase 1. PUT …/branding with any SMOODEV-1813 surface token (background, card, sidebar, …) → 400, verified live. Both rust/api-prime/src/handlers/organization_branding.rs::ThemeOverride (deny_unknown_fields, the live path) and packages/backend/src/routes/organization-branding.ts::ThemeOverrideOpenApi (.strict()) still carry the six Phase-1 fields, while the canonical Zod and the dashboard read path have all 13. The dashboard's own branding settings page sends background, so saving a surface color from the UI is broken in prod today.
  2. PATCH …/branding → 404. The manifest routes it, the TS app registers no PATCH handler. PUT is the only write verb, hence the read-modify-write above.

from-url 404s until Lane A's propose endpoint deploys; that path is coded to the frozen contract and unit-tested against it.

Docs

docs/Engineering/Using-th-CLI.md (new section + cheat sheet) and CLAUDE.md (command inventory, 53 → 54 top-level commands). Changeset included.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Dr6czSxJFJodJ9JkYiiBSp

…he CLI

White-labeling a partner org meant clicking through the dashboard, and the
logo step was the part that never really worked: the settings page takes a
URL, so a partner's logo stayed hosted on the partner's own server. This adds
`th branding` (alias `th brand`) as a top-level command — show / from-url /
set / enable / disable / preview / clear — that treats the logo as bytes to
re-host rather than a link to store.

`--logo`, `--logo-dark` and `--favicon` each take a local path OR a remote
URL. A remote one is fetched and uploaded to the org's brand assets, so what
lands in the branding row is always a URL we serve.

Three deliberate refusals:

- `enable` (and `from-url --enable`) computes WCAG contrast for the three
  foreground/background pairs and stops under 4.5:1. Shipping an unreadable
  dashboard to a partner is the failure this gate exists for; `--force`
  overrides it loudly. The check runs BEFORE the write, not after.
- Remote logo URLs are vetted like the server's `vetUrl` — http(s) only, no
  loopback / RFC1918 / 169.254 (cloud metadata), no redirect following, 5 MB
  cap — and the bytes are magic-byte sniffed against the platform allowlist
  before upload. `.ico` gets a named error instead of an opaque 400.
- Every partial theme edit is a read-modify-write: the server's PUT replaces
  the whole `themeJson` column, so a naive `set --primary` would silently
  wipe every other token the partner had configured.

`from-url` is a dry run by default — swatch table, logo candidates, contrast
verdict, no writes. `--apply` stages (enabled stays false, previewable via
`?brandPreview=1`); `--enable` goes live.

Uploads go through `POST /organizations/:org_id/logo/upload`, the only
endpoint that puts brand images in the PUBLIC-read published-assets bucket —
the presigned `files/upload-url` flow writes to the private bucket whose URLs
403 wherever they render (pearl th-8f3591 / SMOODEV-2566). That endpoint also
stamps `organizations.<variant>`, which is correct for logo and icon but has
no dark slot; rather than borrow `logoWordmark` (it renders in live customer
proposals) the dark upload borrows `logo` and puts the column back.

The Aurora meaning tokens (heat / ai / gradients / ok-warn-crit) encode
meaning, not chrome, and are deliberately unexposed.

Two server-side gaps are surfaced as diagnoses instead of bare errors: the
platform's white-label write validator is still Phase 1 so the surface tokens
(--background, --card, --sidebar, ...) 400 today, and `from-url` 404s until
the propose endpoint deploys.

28 unit tests: theme merge, WCAG ratios against reference values, the enable
gate across server/local/force combinations, the frozen propose fixture,
SSRF vetting, and image sniffing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dr6czSxJFJodJ9JkYiiBSp
@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 851ac5c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@smooai/smooth Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

brentrager and others added 2 commits August 6, 2026 14:58
… isn't a pass

Three corrections from Lane A's real contract and the live chakrasolutions.ai run:

- The propose endpoint OMITS a contrast ratio it couldn't derive, so
  `passes: true` can arrive with zero numbers behind it — an empty proposal
  trivially "passes". Painting a green check over nothing is exactly how an
  unreadable theme would slip past the gate it was supposed to hit, so a
  measureless report now says so and defers to the local check.
- `logoCandidates` can hold several entries of the same kind: the real run
  returned the wordmark AND the page's og:image (a screenshot) both as `logo`.
  First-match-per-kind is still right, but the pick is now marked in the output
  and overridable with --logo / --logo-dark / --favicon on from-url, so a bad
  pick is one flag rather than a re-run plus a follow-up `set`.
- Reworded the 400 diagnosis to name the symptom ("the server's theme schema
  predates the token you set") instead of the two files to edit. Those files
  are moving under Lane A right now; the symptom won't.

Also pins the merge semantics the lead flagged as the likeliest silent
regression once the server stops 400ing: set A, set B, assert A survived, and
assert an untouched token stays absent rather than becoming an explicit null.

Verified live again after the refactor (org "Smoo AI Test", restored after):
sequential sets accumulate, `--accent ''` clears just that token, logo upload
still lands a public URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dr6czSxJFJodJ9JkYiiBSp
…in the row

Answering the lead's check: `set --primary` was already sending only the keys
with values — no 12-null padding, so the CLI never had the dashboard's bug.
But it did echo back nulls it inherited from the stored row, and SMOODEV-2822
shows the dashboard writes exactly those. A strict server rejects on the KEY,
not the value, so one dashboard save would have poisoned every later CLI write
to that org.

`merge_theme` now drops null-valued keys entirely. Safe because PUT replaces
the whole `theme_json` column: an absent key and a null one read identically,
so dropping still clears a token. Side effect worth having — one
`th branding set` heals a poisoned row instead of inheriting it.

Verified live: seeded a dashboard-shaped row
(`{primary:"#7c3aed", accent:null, primaryForeground:null}`), then
`set --accent '#47c4d7'` returned 200 and left `{accent, primary}` with the
nulls gone; `set --accent ''` dropped the key rather than nulling it.

Test pins both halves — no padding, no inherited nulls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dr6czSxJFJodJ9JkYiiBSp
@brentrager
brentrager merged commit 545c336 into main Aug 6, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant